TypeScript 知识点总结

TypeScript

04/08/2021


前言

前言

TypeScript 三大疑问

1. 是什么?

TypeScript(简称 TS)是一门讲 JavaScript 进行封装,对类型进行静态检查的工具 - tools官方是这么说的),简单来说,可以将 TS 理解为 JS 的超集,如果你熟悉 ES5/6,那么 TS 和他们的关系可以形象的描绘成下图

TS 提供了静态类型定义的功能,灵活的接口及复杂对象的定义,使得 TS 成为十分强大的工具,在社区保持着非常高的活跃度,与此同时,TS 也是微软的开源项目,你甚至可以到 github 查看 TS 的源码,提 issue 和 PR 。

2. 为什么?

我们先看看在 JavaScript 项目中最常见的十大错误:

我们是不是耳熟能详?这些低级错误占用了大量 debug 和 google 的时间,而如果你用 TypeScript 可以在编写阶段就规避了,自从我们用了 TypeScript 之后,低级报错基本就没犯过,大多数情况下是我们自身编写的程序逻辑错误。

很多项目,尤其是中大型项目,我们是需要团队多人协作的,那么如何保证协作呢?这个时候可能需要大量的文档和注释,显式类型就是最好的注释,而通过 TypeScript 提供的类型提示功能我们可以非常舒服地调用同伴的代码,由于 TypeScript 的存在我们可以节省大量沟通成本、代码阅读成本等等。

如果上述两个主要优点还不能吸引你的话,那么我可以负责任地告诉你,升职加薪需要 TS,怎么样,香不香 XD

3. 怎么做到的?

分两种模式:

  1. 如果在 tsc 等指令执行之前,IDE 发现了 ts 中的语法错误,这是通过 IDE 和 LSP 来实现的,更为详细的讲解在[下一节](#Ⅱ. TypeScript 工作原理)
  2. Typescript 编译器将对应的 ts 文件,根据你在 tsconfig.json 中指定好的模式,将 ts 文件编译成 js 文件,这个编译器借助 node 引擎实现,并在执行过程中对 ts 代码进行检查,如果发现错误则停止编译,并打印错误信息。如果有 map 文件,还会提示对应 ts 文件中出错的行数

4. 权衡

一个新的技术,在使用之前的调研阶段,通常都会对其进行权衡,因为大部分情况下这项技术一定有优缺点,有它自身的适用场景,那么对于 TS 来说,上面说了那么多优点,下面说一下主要问题:

  • 配置以及用好 TS 需要一定的学习成本,并且由于 TS 技术不如 JS 那么成熟,社区也不如 JS 丰富,对于一些问题解答以及工具数量都远不如 JS 和 node,需要承担一定的风险
  • 如果不打算重写 JS 项目,只是加上 TS 编译器的话,原先的组件接口并不能享受 TS 带来的红利—— IDE 中输入或者鼠标悬浮没有接口参数提示,没有类型检查,这样其实使用 TS 的意义就小了很多。

但是综上来看,绝大部分场景下使用 TS 是利大于弊的,即便是对于个人项目而言,减少不必要的低级错误所省下的时间,通常来说都比你多写一些 TS 类型定义的代码花费时间要多,只是需要你学会 TS 并用好 TS : )

总而言之,你可以将 TS 当做一种语法糖,但 TS 的核心思想是想讲 JS 转换成静态语言,如果微软真的在下一盘大棋,TS 积累了足够的用户群体后,脱离 V8,babel 等编译环境,成为真正的静态语言的话,那就真是将前端彻底洗牌了,不可谓不 NB 啊!当然目前仅仅是对其的猜测,具体如何,需要继续观望,但是无论怎样,使用 TS 依然成为一种流行趋势,TS 确实是一个强大而高效的工具,能够帮助我们在大型 node 项目开发过程中避免一些低级错误以及更好的 IDE 接口提示,提高团队整体的开发效率。

TS 是不是银弹,我们不得而知,但是学好并用好 TS,一定对你的前端生涯有不小的帮助

TypeScript 工作原理

要用好 TS,一个好的 IDE 是必须的,首推 VS Code,因为是微软自家的产品,自然整合的最好。

那么为什么一定要 IDE 呢,这也和 TS 的工作机制有关,TS 的源代码是以两种独立方式处理的:

  • 检查打开的编辑器是否存在错误:这是通过所谓的 language server 完成的。它们是与编辑器无关的方法,可为编辑器提供与语言相关的服务(检测错误、重构、自动完成等)。编辑器(例如 IDE)通过特殊协议(JSON-RPC,即基于 JSON 的远程过程调用)与语言服务器进行通信。这样一来,几乎可以用任何编程语言编写此类服务器。
    • 要记住:language server 仅列出当前打开的编辑器的错误,且不编译 TypeScript,而是仅仅静态分析它。
  • Building(将 TypeScript 文件编译为 JavaScript 文件):在这里,我们有两个选择。
    • 我们可以通过命令行运行构建工具。例如,TypeScript 编译器 tsc--watch 模式,该模式可以监视输入文件,并在更改文件时将其编译为输出文件。这样,每当我们在 IDE 中保存 TypeScript 文件时,都会立即获得相应的输出文件。
    • 我们可以在 Visual Studio Code 中运行 tsc。为此,必须将其安装在我们当前正在开发的项目内部或进行全局安装(通过 Node.js 包管理器 npm)。

通过构建,我们可以获得完整的错误列表。有关在 Visual Studio Code 中编译 TypeScript 的更多信息,请参见该 IDE 的官方文档

给定 TypeScript 文件 main.ts,TypeScript 编译器可以产生几种工件。最常见的是:

  • JavaScript 文件:main.js
  • 声明文件:main.d.ts(包含类型信息)
  • 源码映射文件:main.js.map

TypeScript 通常不是通过 .ts 文件提供的,而是通过 .js 文件和 .d.ts 文件提供:

  • JavaScript 代码包含实际的功能,可以通过普通 JavaScript 使用。
  • 声明文件可帮助编程编辑者实现自动补全和类似的服务。此信息使普通 JavaScript 可以通过 TypeScript 使用。但是如果使用纯 JavaScript,我们甚至会从中受益,因为它可以提供更好的自动完成以及更多功能。

源码映射为 main.js 中输出代码的每一部分指定在 main.ts 中的输入代码的哪一部分生成了它。除其他外,此信息使运行时环境能够执行 JavaScript 代码,同时在错误信息中显示 TypeScript 代码的行号。

TypeScript for Tooling

TypeScript 除了能够发现我们 ts code 中的 bug,还可以在你编辑的时候就帮助你更好地避免产生这些 bug,也就是说在 IDE 中能帮助我们 coding,包括函数调用提示面板,自动修复提示(quick fixes),直接跳转到接口定义等等。这些功能都是通过 type-check 实现的,而以下这些主流 IDE 都对 TS 有良好的支持,并且是跨平台的:

详细可以查看官方文档

the TypeScript compiler

主流的 TS 编译器工具有 3 个:

  • tsc:TS 官方的编译器,作为 type-checker,只有 tsc 可以做 type-checking,代价就是耗时,同时其编译参数也是最丰富的。
  • ts-node:相当于是一个 node 的可执行环境,并且包含 TS 支持的模块可以运行过程中编译 TS,实际使用中就相当于可以直接执行 ts 文件而不用输出 js,另外,ts-node 可以指定 TS 编译器,默认是用 tsc
  • babel + ts-plugin:babel 有自己的 TS 转换插件 @babel/plugin-transform-typescript,但是这个插件相比 tsc 少了一些功能:它没有 type-checking,并且只会转换单个文件,导致其无法理解整个项目的类型系统,如果指定 ts-config 的 isolatedModules 可以告诉 TS 如果有一些文件无法用 babel 这样的单文件转换工具进行正确地解释,就进行 warn 提示

除此之外,还有 swc 以及 Sucrase 等是可兼容 TS 的转换器(注意,是 transpiler,而不是 compiler,只有 tsc 是 compiler)

关于 TS 中的 Errors

如果在 ts 文件中有错误,用 tsc 编译时默认依然会输出 js 文件,甚至有可能你能成功运行该 js 文件,但是行为不符合预期,例如:

TYPESCRIPT
// This is an industrial-grade general-purpose greeter function:
function greet(person, date) {
console.log(`Hello ${person}, today is ${date}!`)
}
greet("Brendan")

tsc 之后你可以得到:

JAVASCRIPT
// This is an industrial-grade general-purpose greeter function:
function greet(person, date) {
console.log("Hello " + person + ", today is " + date + "!")
}
greet("Brendan")

甚至你运行可以看到结果

TEXT
Hello Brendan, today is undefined!

但是 undefined 多半不是我们的预期结果

如果想要让 tsc 在编译时不 skip error,一旦有错就不生成 js 文件的话,可以添加 --noEmitOnError参数,例如上述文件(假设为 ts_study.ts),运行

SHELL
❯ tsc --noEmitOnError ts_study.ts
ts_study.ts:17:1 - error TS2554: Expected 2 arguments, but got 1.
17 greet("Brendan");
~~~~~~~~~~~~~~~~
ts_study.ts:13:24
13 function greet(person, date) {
~~~~
An argument for 'date' was not provided.
Found 1 error.

就会提示报错,并且不生成对应的 js 文件

TypeScript 编译 JavaScript

TypeScript 编译器还可以处理普通的 JavaScript 文件:

  • 使用选项 --allowJs,TypeScript 编译器将输入目录中的 JavaScript 文件复制到输出目录中。好处:当从 JavaScript 迁移到 TypeScript 时,我们可以先使 JavaScript 和 TypeScript 文件混合存在,然后再慢慢把更多 JavaScript 文件转换为 TypeScript 。
  • 使用选项 --checkJs,编译器还会对 JavaScript 文件进行类型检查(必须启用 --allowJs 才能使该选项起作用)。鉴于可用信息有限,它会尽其所能。
  • 如果 JavaScript 文件包含注释 //@ts-nocheck,则不会对其进行类型检查。
    • 如果没有 --checkJs,注释 //@ts-check 可用于对单个 JavaScript 文件进行类型检查。
  • TypeScript 编译器使用通过 JSDoc 注释指定的静态类型信息(请参见下面的例子)。如果可以的话,我们可以完全静态类型化纯 JavaScript 文件,甚至可以派生它们的声明文件。
  • 使用选项 --noEmit,编译器不会产生任何输出,它只会对文件进行类型检查。

This is an example of a JSDoc comment that provides static type information for a function add(): 这是一个 JSDoc 注释的例子,它为函数 add() 提供静态类型信息:

TEXT
/**
* @param {number} x - A number param.
* @param {number} y - A number param.
* @returns {number} This is the result
*/
function add(x, y) {
return x + y;
}

详细信息:《 TypeScript 手册》中的 Type-Checking JavaScript Files

关于打包

npm 注册表是一个巨大的 JavaScript 代码库。如果要使用 TypeScript 中的 JavaScript 包,则需要类型信息:

  • 软件包本身可能包含 .d.ts 文件,甚至完整的 TypeScript 代码。
  • 如果没有,我们仍然可以使用它:DefinitelyTyped是人们为普通 JavaScript 包编写的声明文件的库。

DefinitelyTyped 的声明文件位于 @types 命名空间中。所以如果我们需要像 lodash 这样的包的声明文件,则必须安装 @types/lodash 包。

1. 基础类型

1. 基础类型

以下左边对应 JavaScript,右边是 TypeScript

  • Boolean:boolean
  • Number:number
  • String:string
  • 空值:void( TS 中的 void 并不是 JS 中的 void )
  • Null:null
  • Undefined:undefined
  • Symbol:symbol
  • BigInt:bigint

可以看出基本上 TS 中的基本类型就是讲 JS 中的各种基本类型的原型名改为小写

需要注意,void 在 JS 中是一个操作符,表示后面的function或者expression总是返回 undefined,例如:

JAVASCRIPT
let i = void 2 // i === undefined
void (function aRecursion(i) {
if (i > 0) {
console.log(i--)
aRecursion(i)
}
})(3)
console.log(typeof aRecursion) // undefined

至于好处就是,如果你的 app 里某个接口要求必须返回 undefined 或者在异常时只接受 undefined 的结果,其他结果会报错,那么用 void 可以保证 app 不会 crash:

JAVASCRIPT
// returning something else than undefined would crash the app
function middleware(nextCallback) {
if (conditionApplies()) {
return void nextCallback()
}
}
// or
button.onclick = () => void doSomething()

而 TS 中的 void 是一种类型,相当于 undefined 的一个子类,表示函数不返回值,而在 js 里如果函数没有显示调用 return,则默认返回 undefined 作为函数返回值。然而需要注意,TS 中,void 不等同于 undefined,TS 官网对 void 的定义:

void represents the return value of functions which don’t return a value. It’s the inferred type any time a function doesn’t have any return statements, or doesn’t return any explicit value from those return statements:

注意最后一句话 or doesn’t return any explicit value from those return statements:,意思就是你虽然定义了 void,但是作为函数可以返回任何值,所以如果你用 undefined 就会报错但是 void 不会:

TYPESCRIPT
function doSomething(callback: () => void) {
// at this position, callback always returns undefined
let c = callback()
//c is also of type undefiend
}
// this function returns a number
function aNumberCallback(): number {
return 2;
}
// works 👍 type safety is ensured in doSometing
doSomething(aNumberCallback)
// if you change the code above like
- function doSomething(callback: () => void) {
+ function doSomething(callback: () => undefined) { /* ... */ }
function aNumberCallback(): number { return 2; }
// 💥 types don't match
doSomething(aNumberCallback)

官网的解释说 void 的含义应当是不指望函数返回任何结果,实际上函数你返回什么东西我不 care,所以导致这个结果,我觉得也 make sense,不然直接用 undefined 不就好了

P.S. 补充

  1. 很多时候我们并不需要显示声明变量的类型,例如
TYPESCRIPT
let msg: string = "hello there!"
// equal to
let msg = "hello there!"

因为 TypeScript 能够推断(infer)出 msg 变量是 string,并且在 IDE 中,当鼠标 hover 在 msg 上时,会自动弹出 TS 的类型提示

  1. TS 可以做 downleveling :downleveling 是指在编译 TS 到 JS 时,对应的 ECMAScript 版本,TS 能够直接将 TS 代码转换成低版本的 ECMAScript, 通过 --target 参数可以在 tsc 中指定, 例如:
SHELL
tsc --target es2015 input.ts

​ 或者在 tsconfig.json (babel)中指定

  1. 最主要、实用的 2 个 TS 编译选项(in tsconfig.json):
    • noImplicitAny:如果 TS 推测一个变量类型是 any,则会抛出错误,除非你显示指定变量类型是 any ,因为如果变量都是 any,那也失去了实用 TypeScript 的意义
    • strictNullChecks:TS 中,nullundefined 默认可以赋值任何值,因为任何类型都不能明确定义不包含 nullundefined,但是不处理 nullundefined 会造成很多错误,例如注明的billion dollar mistake。而在严格空检查模式(strictNullChecks)下, nullundefined 值都 属于任何一个类型,它们只能赋值给自己这种类型或者 any (有一个例外,undefined 也可以赋值给 void)。因此,在常规类型检查模式下 TT | undefined 被认为是等同的(因为 undefined 被看作 T 的子类型),但它们在严格类型检查模式下是不同的类型,只有 T | undefined 类型允许出现 undefined 值。TT | null 也是这种情况。

1* 其他常见类型

1* 其他常见类型

unknown

unknown 大多数时候很 any 很像,代表着变量可以使任何未知的类型,区别是 unknown 变量不能够访问属性或者调用对象上绑定的函数,而 any 可以,因此可以认为 unknown 更为安全

never

never 类型表示的是那些永不存在的值的类型,never 类型是任何类型的子类型,也可以赋值给任何类型;然而,没有类型是 never 的子类型或可以赋值给 never 类型(除了 never 本身之外)。

即使 any 也不可以赋值给 never。

两个场景中 never 比较常见:

TEXT
// 抛出异常的函数永远不会有返回值
function error(message: string): never {
throw new Error(message);
}
// 空数组,而且永远是空的
const empty: never[] = []

元组(Tuple)

元组类型与数组类型非常相似,表示一个已知元素数量和类型的数组,各元素的类型不必相同。

比如,你可以定义一对值分别为stringnumber类型的元组。

TEXT
let x: [string, number];
x = ['hello', 10, false] // Error
x = ['hello'] // Error

我们看到,这就是元组与数组的不同之处,元组的类型如果多出或者少于规定的类型是会报错的,必须严格跟事先声明的类型一致才不会报错。

那么有人会问,我们的类型完全一致,只是顺序错了有没有问题,比如上个例子中我们把 stringnumber 调换位置:

TEXT
let x: [string, number];
x = ['hello', 10]; // OK
x = [10, 'hello']; // Error

我们看到,元组非常严格,即使类型的顺序不一样也会报错。

元组中包含的元素,必须与声明的类型一致,而且不能多、不能少,甚至顺序不能不符。

我们可以把元组看成严格版的数组,比如[string, number]我们可以看成是:

TEXT
interface Tuple extends Array<string | number> {
0: string;
1: number;
length: 2;
}

元组继承于数组,但是比数组拥有更严格的类型检查。

Object

object 表示非原始类型,也就是除 number,string,boolean,symbol,null 或 undefined 之外的类型。

TEXT
// 这是下一节会提到的枚举类型
enum Direction {
Center = 1
}
let value: object
value = Direction
value = [1]
value = [1, 'hello']
value = {}

我们看到,普通对象、枚举、数组、元组通通都是 object 类型。

Enum

关于 Enum 的详细讲解可以查看博客第 6 节,这里主要讲一下枚举的本质:

例如如下 ts 代码

TYPESCRIPT
enum Direction {
Up = 10,
Down,
Left,
Right,
}

在经过 tsc 编译过后会得到:

JAVASCRIPT
var Direction
;(function (Direction) {
Direction[(Direction["Up"] = 10)] = "Up"
Direction[(Direction["Down"] = 11)] = "Down"
Direction[(Direction["Left"] = 12)] = "Left"
Direction[(Direction["Right"] = 13)] = "Right"
})(Direction || (Direction = {}))

从而你可以得到 Direction[10] === 'Up' 以及 Direction['Up'] === 10,也就是 key <=> value 双向绑定,所以 Enum 实际上就是一个经过特殊处理的 Object

查看如下代码:

TYPESCR
enum Enum {
A,
}
let a = Enum.A;
let nameOfA = Enum[a]; // "A"

这样可以看到反向映射的作用,不过实际应用场景应该不多

另外关于 const enums 和 enums vs object,可以查看官网的说明,这里就不再赘述

字面量类型 (Literal Types)

是的,你没看错,字面量也可以成为类型,不仅如此,数字也可以,但是这种类型就意味着只能赋值一个值:

TYPESCRIPT
let x: "hello" = "hello"
// OK
x = "hello"
// Type '"howdy"' is not assignable to type '"hello"'.
x = "howdy"

那么这种字面量类型有什么用呢?

通常,是配合 Union,保证这个类型只接受这几种值,否则就认为是错误,从而增强安全性:

TYPESCRIPT
function printText(s: string, alignment: "left" | "right" | "center") {
// ...
}
printText("Hello, world", "left")
printText("G'day, mate", "centre")
// Argument of type '"centre"' is not assignable to parameter of type '"left" | "right" | "center"'.

数字字面量类型 (Numeric literal types)

TYPESCRIPT
function compare(a: string, b: string): -1 | 0 | 1 {
return a === b ? 0 : a > b ? 1 : -1
}

当然,你可以 Union 字面量和非字面量类型

TYPESCRIPT
interface Options {
width: number
}
function configure(x: Options | "auto") {
// ...
}
configure({ width: 100 })
configure("auto")
configure("automatic")
// Argument of type '"automatic"' is not assignable to parameter of type 'Options | "auto"'.

boolean 也是一样的,就不再赘述

对象中的字面量类型

如果是对象中的属性,TypeScript 会将其推断为更通用的类型,例如

TYPESCRIPT
const obj = { counter: 0 }
if (someCondition) {
obj.counter = 1
}

因为 TS 推断 couter 是 number 而不是类型 0,再看下面这个例子

TYPESCRIPT
const req = { url: "https://example.com", method: "GET" }
handleRequest(req.url, req.method)
// Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

假设 handleRequest 要求 method 是字面量类型 'GET'|'POST',这时候就需要 method 是字面量类型而不是 string 了,那么该怎么办呢?答案是用 as:

TYPESCRIPT
// Change 1:
const req = { url: "https://example.com", method: "GET" as "GET" }
// Change 2
handleRequest(req.url, req.method as "GET")

2 种写法都 work,除此之外,你可以在整个对象声明的时候就用 as const,因为这里的 const 只在 TS 中识别处理,因此会当做对象属性是字面量类型而不是 JS 中属性不可变的意思了:

TYPESCRIPT
const req = { url: "https://example.com", method: "GET" } as const
handleRequest(req.url, req.method)

这里,req.method 就会被当成 "GET" 类型从而通过 TS 检查

2. Type 和 Interface

2. Type 和 Interface

Type 和 Interface 的基本使用就不再说明,很容易搜到,这里主要讨论下它们之间的区别:

主要区别是 type 一旦定义就不能添加新的属性(properties),而 interface 可以,可以参考官网的例子 (下面也给出了相关 code,但是由于没能解决 MDX 渲染 code block 的问题,所以没有官网展示得清晰)

TYPESCRIPT
// Extending an interface
interface Animal {
name: string
}
interface Bear extends Animal {
honey: boolean
}
const bear = getBear()
bear.name
bear.honey
// Extending a type via intersections
type Animal = {
name: string
}
type Bear = Animal & {
honey: Boolean
}
const bear = getBear()
bear.name
bear.honey
// Adding new fields to an existing interface
interface Window {
title: string
}
interface Window {
ts: TypeScriptAPI
}
const src = 'const a = "Hello World"'
window.ts.transpileModule(src, {})
// A type cannot be changed after being created
type Window = {
title: string
}
type Window = {
ts: TypeScriptAPI
}
// Error: Duplicate identifier 'Window'.

另外还有以下几点:

  • type 在 TS 4.2 之前或许会在错误提示中显示一些匿名类型(而非你指定的类型),这个结果或许不是你想要的,可以查看官网的PlayGround,当你选择 TS 版本低于 4.2,并且鼠标 hover 在最下面的错误代码时,提示信息中并不会包含你指定的类名
  • type 能够自定义命名原始数据类型(primitive),而 interface 不行
  • 虽然 TS 是根据形状识别类型的(structural typing),但是如果不显示指定 interface 名而导致的报错,则不会显示该 interface 名,具体可以看下面的例子
TYPESCRIPT
// Compiler error messages will always use
// the name of an interface:
interface Mammal {
name: string
}
function echoMammal(m: Mammal) {
console.log(m.name)
}
// e.g. The error below will always use the name Mammal
// to refer to the type which is expected:
echoMammal({ name: 12343 })
// The type of `m` here is the exact same as mammal,
// but as it's not been directly named, TypeScript
// won't mention it in the error messaging
function echoAnimal(m: { name: string }) {
console.log(m.name)
}
echoAnimal({ name: 12345 })

大多数时候,你可以根据个人喜好选择使用 type 还是 interface,如果你喜欢启发式编程,则考虑使用 interface, 直到你需要 type的一些特性

2021-04-09 更新

[Type Casting] 很多时候我们可能会在 cast 类型(as)的时候遇到问题,因为 Type Casting 要求转换的类型必须是更为精确的类型,否则不能转换,例如:

TYPESCRIPT
undefined as number

就会报错,因为 number 并不是 undefined 的一个子类型,更进一步说,如果两个类型有交叉但不是继承也不行

但是,再看看下面的代码 XD

TYPESCRIPT
interface A {
a1: number
a2: Record<string, unknown>
}
declare let b: Record<string, unknown>
b as A // error
;(b as unknown) as A // pass

按理说 b 应该是可以 cast 给 A 的,但是并不行,而这,似乎是 TypeScript 的 bug!如果将 interface 改为 type 就可以了,否则你就需要做两层转换,(b as unknown) as A,其实和 b as any 差不多,这样 TS 系统失去了很多意义,但是相对 any 而言,转换为 unknown 更安全,因为不能访问变量内部属性

关于这个 bug,在源码 repo 上有一个 issue 对其进行了详细的讨论,然而非常遗憾,到目前(TypeScript 4.2)依然没能 fix 这个 bug。有一位总结说:

  1. A specific type can be saved into a more generic type.
  2. A specific interface cannot be saved into a more generic interface. (unexpected)
  3. A specific interface can extend an more generic interface.
  4. A specific type can be saved into a more generic interface.
  5. A specific interface cannot be saved into a more generic type.

看来 typeinterface 好用一些,但是个人观点是好用通常意味着相对不安全,可以看出 interface 的检查更严格,虽然这里是错误检查了……另外,很多知名的 lib (redux,rxjs)里还是使用 interface 的居多,估计也是由于上文所说的 interface 易于扩展的缘故,所以即便有这个 bug,还是优先考虑使用 interface。

3. 函数、泛型

3. 函数、泛型

函数的参数和返回值类型定义就不多说了,比较基础,说一下进阶的东西。

首先,函数可以通过 type, interface 定义,并且 typeinterface 可以是包含 function 属性的 object,因为 JS 中的 Function 也是对象,所以只要是 function 的属性,都可以定义。例如:

TYPESCRIPT
type DescribableFunction = {
description: string
(someArg: number): boolean
}
function doSomething(fn: DescribableFunction) {
console.log(fn.description + " returned " + fn(6))
}

这里定义了 fn 的 description 类型,而 DescribableFunction 依然是可以调用的函数

  • 构造函数 Constructor

对于构造函数,需要使用 new 关键字,而在 TS 中,同样可以定义构造函数,也是用 new

TYPESCRIPT
type SomeConstructor = {
new (s: string): SomeObject
}
function fn(ctor: SomeConstructor) {
return new ctor("hello")
}

另外,比如 JS 中的 Date,既可以通过 new 构造,也可以不用 new,直接调用(作为函数),就可以如下定义:

TYPESCRIPT
interface CallOrConstruct {
new (s: string): Date
(n?: number): number
}
  • 泛型 Generic

属性 Java 或者 C++ 的应该对泛型变成也比较属性,简单来说,泛型就是我不指定具体的类型,但是我知道这些变量是同一种类型,例如:

TYPESCRIPT
function firstElement<Type>(arr: Type[]): Type {
return arr[0]
}
// s is of type 'string'
const s = firstElement(["a", "b", "c"])
// n is of type 'number'
const n = firstElement([1, 2, 3])

而泛型的类型 T,可以通过 extendskeyof 等关键字进行约束:

TYPESCRIPT
function longest<Type extends { length: number }>(a: Type, b: Type) {
if (a.length >= b.length) {
return a
} else {
return b
}
}
// longerArray is of type 'number[]'
const longerArray = longest([1, 2], [1, 2, 3])
// longerString is of type 'string'
const longerString = longest("alice", "bob")
// Error! Numbers don't have a 'length' property
const notOK = longest(10, 100)

再来看下面这个例子,看上去仿佛没有问题,但 TS 会报错

TYPESCRIPT
function minimumLength<Type extends { length: number }>(
obj: Type,
minimum: number
): Type {
if (obj.length >= minimum) {
return obj
} else {
return { length: minimum }
// Type '{ length: number; }' is not assignable to type 'Type'. '{ length: number; }' is assignable to the constraint of type 'Type', but 'Type' could be instantiated with a different subtype of constraint '{ length: number; }'.
}
}

你说返回值符合 { length: number } 了啊?这也是常见错误,因为我们要求函数返回的是 Type,它虽然继承了 { length: number },但很有可能有比父类更丰富的功能,而如果 TS 只要求满足父类的约束,就会导致下面的代码也通过:

TYPESCRIPT
// 'arr' gets value { length: 6 }
const arr = minimumLength([1, 2, 3], 6)
// and crashes here because arrays have
// a 'slice' method, but not the returned object!
console.log(arr.slice(0))

另外,通常来说泛型的类型是 TS 自动推测(infer)的,但是你也可以自己指定类型,例如:

TYPESCRIPT
function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
return arr1.concat(arr2)
}
// Type 'string' is not assignable to type 'number'.
const arr = combine([1, 2, 3], ["hello"])
// Correct
const arr = combine<string | number>([1, 2, 3], ["hello"])

如果不指定,TS 会推测类型为 number,从而报错。

泛型接口

泛型也可用于接口声明,以上面的函数为例,如果我们将其转化为接口的形式。

TYPESCRIPT
interface ReturnItemFn<T> {
(para: T): T
}

那么当我们想传入一个number作为参数的时候,就可以这样声明函数:

TYPESCRIPT
const returnItem: ReturnItemFn<number> = para => para

泛型类 泛型除了可以在函数中使用,还可以在类中使用,它既可以作用于类本身,也可以作用与类的成员函数。 我们假设要写一个栈数据结构,它的简化版是这样的:

TYPESCRIPT
class Stack {
private arr: number[] = []
public push(item: number) {
this.arr.push(item)
}
public pop() {
this.arr.pop()
}
}

同样的问题,如果只是传入 number 类型就算了,可是需要不同的类型的时候,还得靠泛型的帮助。

TYPESCRIPT
class Stack<T> {
private arr: T[] = []
public push(item: T) {
this.arr.push(item)
}
public pop() {
this.arr.pop()
}
}

泛型类看上去与泛型接口差不多, 泛型类使用 <> 括起泛型类型,跟在类名后面。

泛型编程虽然有趣,但有一些原则应当遵循:

Rule 1: When possible, use the type parameter itself rather than constraining it

我们应当尽量使用更少的 TS 关键字定义泛型,否则因为过于复杂 TS 很可能无法推断出变量的类型,当你鼠标 hover 在变量上时,就不能知道它是什么类型了、

TYPESCRIPT
function firstElement1<Type>(arr: Type[]) {
return arr[0]
}
function firstElement2<Type extends any[]>(arr: Type) {
return arr[0]
}
// a: number (good)
const a = firstElement1([1, 2, 3])
// b: any (bad)
const b = firstElement2([1, 2, 3])

Rule 2: Always use as few type parameters as possible

另外一个例子

TYPESCRIPT
function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] {
return arr.filter(func)
}
function filter2<Type, Func extends (arg: Type) => boolean>(
arr: Type[],
func: Func
): Type[] {
return arr.filter(func)
}

filter1 比 filter2 要好,因为 filter2 凭空创建了一个不需要的泛型 Func,对于函数调用方,必须指定一个无用的类型去适配 Func,同时,降低了可读性

Rule: If a type parameter only appears in one location, strongly reconsider if you actually need it

有时候我们并不需要定义泛型,而我们可能并未注意

TYPESCRIPT
function greet<Str extends string>(s: Str) {
console.log("Hello, " + s)
}
greet("world")
function greet(s: string) {
console.log("Hello, " + s)
}

只有当 Str 使用超过 2 次的时候,我们才应当考虑使用它

最后,来看看函数重载(Function Overloads)

对于 JS 来说,是不存在函数重载的,因为函数名必须一致,但是在 TS 中,有 Function Overloads 的概念,不过 TS 中的重载和传统语言 JAVA|C++ 不一样,TS 中可以有多个函数定义,但是只能有一个函数实现,不然就无法编译成 JS 文件了,例如:

TYPESCRIPT
function makeDate(timestamp: number): Date
function makeDate(m: number, d: number, y: number): Date
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
if (d !== undefined && y !== undefined) {
return new Date(y, mOrTimestamp, d)
} else {
return new Date(mOrTimestamp)
}
}
const d1 = makeDate(12345678)
const d2 = makeDate(5, 5, 5)
const d3 = makeDate(1, 3)

在 VS Code 中,如果你将鼠标 hover 在 makeDate 调用的地方(d1,d2,d3),会分别看到具体调用的是哪个重载的函数版本,这样对于开发人员来说无疑是一件幸福的事情,代码变得更可读了~

P.S. 另外可以看到,如果存在参数个数不匹配的情况(2 个),TS 会链接到参数个数少于它的那一个定义

重载的函数定义必须符合函数实现,否则 TS 报错:

TYPESCRIPT
function fn(x: string): void;
function fn() {
// ...
}
// Expected to be able to call with zero arguments
fn();
Expected 1 arguments, but got 0.
TYPESCRIPT
function fn(x: boolean): void
// Argument type isn't right
function fn(x: string): void
// This overload signature is not compatible with its implementation signature.
function fn(x: boolean) {}

对于相同个数的参数,应当尽量考虑使用 Union 而非重载,这样错误提示可读性更强:

TYPESCRIPT
function len(s: string): number
function len(arr: any[]): number
function len(x: any) {
return x.length
}
len("") // OK
len([0]) // OK
len(Math.random() > 0.5 ? "hello" : [0])
/**
No overload matches this call.
Overload 1 of 2, '(s: string): number', gave the following error.
Argument of type 'number[] | "hello"' is not assignable to parameter of type 'string'.
Type 'number[]' is not assignable to type 'string'.
Overload 2 of 2, '(arr: any[]): number', gave the following error.
Argument of type 'number[] | "hello"' is not assignable to parameter of type 'any[]'.
Type 'string' is not assignable to type 'any[]'.
*/
// this is better
function len(x: any[] | string) {
return x.length
}

4. 常用工具类

4. 常用工具类

用 JavaScript 编写中大型程序是离不开 lodash 这种工具集的,而用 TypeScript 编程同样离不开类型工具的帮助,类型工具就是类型版的 lodash. 我们在本节会介绍一些类型工具的设计与实现,如果你的项目不是非常简单的 demo 级项目,那么在你的开发过程中一定会用到它们。 起初,TypeScript 没有这么多工具类型,很多都是社区创造出来的,然后 TypeScript 陆续将一些常用的工具类型纳入了官方基准库内。 我们之前提到过的 ReturnTypePartialConstructorParametersPick 都是官方的内置工具类型. 其实上述的工具类型都可以被我们开发者自己模拟出来,本节我们学习一下如何设计工具类型.

  • 工具类型的设计

泛型

我们说过可以把工具类型类比 js 中的工具函数,因此必须有输入和输出,而在TS的类型系统中能担当类型入口的只有泛型. 比如之前我们提到过的 Partial ,它的作用是将属性全部变为可选.

TYPESCRIPT
type Partial<T> = { [P in keyof T]?: T[P] };

这个类型工具中,我们需要将类型通过泛型T传入才能对类型进行处理并返回新类型,可以说,一切类型工具的基础就是泛型.

类型递归

是的,在类型中也有类似于js递归的操作,上面提到的Partial可以把属性变为可选,但是他有个问题,就是无法把深层属性变成可选,只能处理外层属性:

TYPESCRIPT
interface Company {
id: number
name: string
}
interface Person {
id: number
name: string
adress: string
company: Company
}
type R1 = Partial<Person>

这里想处理深层属性,就必须用到类型递归:

TYPESCRIPT
type DeepPartial<T> = {
[U in keyof T]?: T[U] extends object
? DeepPartial<T[U]>
: T[U]
};
type R2 = DeepPartial<Person>

这个原理跟js类似,就是对外层的value做个判断,如果恰好是object类型,那么对他也进行属性可选化的操作即可.

关键字

keyoftypeof 这种常用关键字我们已经了解过了,现在主要谈一下另外一些常用关键字. + -这两个关键字用于映射类型中给属性添加修饰符,比如 -? 就代表将可选属性变为必选,-readonly 代表将只读属性变为非只读. 比如TS就内置了一个类型工具 Required<T> ,它的作用是将传入的属性变为必选项:

TYPESCRIPT
type Required<T> = { [P in keyof T]-?: T[P] };

当然还有很常用的Type inference就是上一节infer关键字的使用,还有之前的Conditional Type条件类型都是工具类型的常用手法,在这里就不多赘述了。

  • 常见工具类型的解读

Omit

Omit 这个工具类型在开发过程中非常常见,以至于官方在3.5版本正式加入了 Omit 类型. 要了解之前我们先看一下另一个内置类型工具的实现 Exclude<T>:

TYPESCRIPT
type Exclude<T, U> = T extends U ? never : T;
type T = Exclude<1 | 2, 1 | 3> // -> 2

Exclude 的作用是从 T 中排除出可分配给 U的元素. 这里的可分配即 assignable,指可分配的, T extends UT 是否可分配给 U

那么 ExcludeOmit 有什么关系呢? 其实 Omit = Exclude + Pick

TYPESCRIPT
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>
type Foo = Omit<{name: string, age: number}, 'name'> // -> { age: number }

Omit<T, K> 的作用是忽略 T 中的某些属性.

Merge

Merge<O1, O2> 的作用是将两个对象的属性合并:

TYPESCRIPT
type O1 = {
name: string
id: number
}
type O2 = {
id: number
from: string
}
type R2 = Merge<O1, O2>

结果

这个类型工具也非常常用,他主要有两个部分组成: Merge<O1, O2> = Compute<A> + Omit<U, T> Compute 的作用是将交叉类型合并.即:

TYPESCRIPT
type Compute<A extends any> =
A extends Function
? A
: { [K in keyof A]: A[K] }
type R1 = Compute<{x: 'x'} & {y: 'y'}>

结果如下: Merge的最终实现如下:

TYPESCRIPT
type Merge<O1 extends object, O2 extends object> =
Compute<O1 & Omit<O2, keyof O1>>

Intersection

Intersection<T, U> 的作用是取 T 的属性,此属性同样也存在与 U.

TYPESCRIPT
type Props = { name: string; age: number; visible: boolean };
type DefaultProps = { age: number };
// Expect: { age: number; }
type DuplicatedProps = Intersection<Props, DefaultProps>;

IntersectionExtractPick 的结合

Intersection<T, U> = Extract<T, U> + Pick<T, U>

TYPESCRIPT
type Intersection<T extends object, U extends object> = Pick<
T,
Extract<keyof T, keyof U> & Extract<keyof U, keyof T>
>;

Overwrite

Overwrite<T, U> 顾名思义,是用U的属性覆盖T的相同属性.

TYPESCRIPT
type Props = { name: string; age: number; visible: boolean };
type NewProps = { age: string; other: string };
// Expect: { name: string; age: string; visible: boolean; }
type ReplacedProps = Overwrite<Props, NewProps>

即:

TYPESCRIPT
type Overwrite<
T extends object,
U extends object,
I = Diff<T, U> & Intersection<U, T>
> = Pick<I, keyof I>;

MutableT 的所有属性的 readonly 移除

TYPESCRIPT
type Mutable<T> = {
-readonly [P in keyof T]: T[P]
}
  • 小结 本节我们介绍了几种常见的高级类型工具,尤其是前三个非常常用,当然如果你想进一步学习类型工具的设计,建议阅读utility-types的源码,本节部分实现就是源于此类型工具库.

单例 => NameSpace/Module

单例 => NameSpace/Module

【2020-04-13 更新】今天有这么一个需求,希望用 TS 实现单例模式,并且支持泛型 T 来定义类型。于是折腾半天实现如下代码:

TYPESCRIPT
export class SingletonService<T> {
readonly type: T
readonly newable: new() => T
private instance: T
constructor(newable: new() => T) {
this.newable = newable;
}
getInstance(): T {
if(!this.instance) {
this.instance = new this.newable();
}
return this.instance;
}
}
class myNumberClass {
num: number;
constructor() {
this.num = 0;
}
add() {
this.num++;
}
}
let myNumberSS = new SingletonService<myNumberClass>(myNumberClass)
const ss: myNumberClass = myNumberSS.getInstance();
console.log(ss.num)
ss.add();
console.log(ss.num)
ss.add();
const ss2: myNumberClass = myNumberSS.getInstance();
console.log(ss2.num)

看上去没有问题,可是首先,instance 是可以改变的,因为 JS 是按引用传递,通过 getInstance 得到 instance 之后就任你操作了,并不安全,当然你可以通过 readonly 指定,并在 constructor 里面给它赋值,不过这样也不够。

因为关键问题是:

TS 并没有静态类的机制来支持我们做单例,这本身就是反范式的,例如上面 ss 和 ss2 其实都是通过 myNumberSS 得到,那如果我们又声明了一个 myNumberSS2 ?岂不是又不符合单例的原则了,而且说实话new SingletonService<myNumberClass>(myNumberClass) 这种 code 我也是看吐了,但是又没更好的办法,因为泛型里的类型是 TS 指定的,跟实例无关,所以我拿不到构造函数,最理想的写法是 new SingletonService<myNumberClass>(),但并做不到,并且还有上面说的问题,所以用 TS 泛型写单例就是 GG

在网上搜索求助半天,最后了解到原来对于这个需求,在 TS 中应当考虑用 namespace 命名空间或者 module 模块,ES Module 是很熟悉了,那 namespace 呢? 其实也是相当于在 JS 中定义了自己的一个私有作用域,内部的变量和函数都是私有的,对外不可见的,只有通过 export 才能暴露给外部,是不是很像闭包的定义!我去查了一下,虽然没有官方明确指出,但八九不离十,大家也都认为 namespace 就是通过闭包实现的,由于 namespace 绑定在全局作用域上,从而实现了”单例“的效果!

  • namespace vs module

但是又看深入浅出 typescript里说

对大部分使用者来说,namespace 可以用模块来替代。

???折腾半天等于 namespace 不推荐,阿西。又去查了一下,官网是这么说的:

Modules can contain both code and declarations.

Modules also have a dependency on a module loader (such as CommonJs/Require.js) or a runtime which supports ES Modules. Modules provide for better code reuse, stronger isolation and better tooling support for bundling.

It is also worth noting that, for Node.js applications, modules are the default and we recommended modules over namespaces in modern code.

Starting with ECMAScript 2015, modules are native part of the language, and should be supported by all compliant engine implementations. Thus, for new projects modules would be the recommended code organization mechanism.

也就是说,模块提供了比较好的特性,支持 CommonJS 加载,是 nodejs 默认模块方式,所以建议在 ES6 之后的代码中使用 module 而非 namespace。alright,所以折腾半天,最后还是用 import 和 export,[呵呵]

关于模块化相关知识(包括 AMD,CMD 等),还没系统学习整理,之后学到了再另起一篇,这里就不再赘述